cl/forkchoice: harden Gloas envelope persistence - #23152
Conversation
There was a problem hiding this comment.
Pull request overview
Hardens Gloas execution payload envelope persistence in the fork-choice fork graph by making disk writes atomic/durable, reads ownership-safe (no shared buffer aliasing), and pruning/write/read operations serialized to avoid resurrecting or re-exposing pruned/corrupt envelopes.
Changes:
- Switch envelope persistence to temp-file write + atomic rename + directory sync, plus startup cleanup of orphan temp/quarantine artifacts.
- Make envelope reads decode from owned buffers, enforce root identity, and quarantine/remove structurally corrupt envelope files.
- Add stronger envelope validation (Gloas-only, size bounds, complete representation) and tests covering races, corruption, and failure modes.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| cl/phase1/forkchoice/payload_vote_test.go | Extends test fork-graph stub to simulate dump errors. |
| cl/phase1/forkchoice/on_execution_payload.go | Routes persistence through persistEnvelope to downgrade “committed with durability warning” to a logged warning. |
| cl/phase1/forkchoice/on_execution_payload_test.go | Adds regression test ensuring bookkeeping completes when persistence returns a committed-warning marker. |
| cl/phase1/forkchoice/fork_graph/interface.go | Introduces ErrEnvelopeCommitted marker error for “committed but durability warning” semantics. |
| cl/phase1/forkchoice/fork_graph/fork_graph_test.go | Adds extensive tests for atomicity, corruption handling, quarantine, pruning/write serialization, and ownership/race safety. |
| cl/phase1/forkchoice/fork_graph/fork_graph_disk.go | Serializes prune with envelope operations, quarantines pruned envelopes, removes temps, and syncs directory after prune. |
| cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go | Implements envelope temp naming, artifact cleanup, directory sync helper, quarantine helper, and hardened read/write paths. |
| cl/cltypes/execution_requests.go | Exposes ExecutionRequests.Version() for safer version checks in persistence validation. |
Suppressed comments (1)
cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go:102
- removeOrQuarantineEnvelope returns the original Remove error even when the quarantine Rename succeeds. That makes callers treat a successful quarantine as a hard failure (e.g., Prune joins and returns an error), and the log message in ReadEnvelopeFromDisk can claim removal failed even though the file was successfully moved out of the active name. Consider returning nil on successful quarantine, and only returning an error when both Remove and Rename fail (or when Rename fails with something other than IsNotExist).
if renameErr := fs.Rename(filename, filename+suffix); renameErr != nil && !os.IsNotExist(renameErr) {
return errors.Join(err, fmt.Errorf("quarantine envelope: %w", renameErr))
}
return err
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
awskii
left a comment
There was a problem hiding this comment.
Review of the envelope-persistence hardening. Main concerns: HasEnvelope now takes the beacon-state dump lock on a path that runs under f.mu; a decode failure now destroys the file; and because cmd/caplin/caplin1/run.go:346 wipes the fork-choice directory on every start, most of the new durability machinery cannot fire in production. Details inline.
d2de0bc to
9a4d23f
Compare
9a4d23f to
6ad6496
Compare
| builderIndex: builderIndex, | ||
| } | ||
| if s.seenEnvelopesCache.Contains(seenKey) { | ||
| if s.seenEnvelopesCache.Contains(seenKey) && s.forkchoiceStore.HasEnvelope(beaconBlockRoot) { |
There was a problem hiding this comment.
Gossip dedup is now bypassable. Adding && s.forkchoiceStore.HasEnvelope(beaconBlockRoot) means the seen-cache no longer short-circuits on its own.
HasEnvelope is false whenever the root sits in invalidEnvelopes (any structurally-corrupt read marks it, and the file is deliberately kept) or whenever envelopeExists was evicted. In that state every replay of the same envelope from the same builder falls through to OnExecutionPayload, which re-runs BLS verification and engine_newPayload, and re-emits execution_payload_available on SSE. A peer that can get one root into that state gets unbounded CPU + EL amplification from a single stored envelope.
If the goal is "retry when persistence failed", key that on a persistence-failure marker rather than on the general availability predicate.
| f.fs.Remove(getEnvelopeFilename(root)) | ||
| f.invalidEnvelopes.Delete(root) | ||
| f.stateDumpLock.Unlock() | ||
| if removeErr := f.fs.Remove(getEnvelopeFilename(root)); removeErr != nil && !errors.Is(removeErr, fs.ErrNotExist) { |
There was a problem hiding this comment.
The envelope file is removed after stateDumpLock is released, so the cache/file invariant the PR sets out to establish still has a window.
envelopeExists.Delete(root) happens on line 621 under the lock, but Remove(getEnvelopeFilename(root)) runs after Unlock(). Between the two, a DumpEnvelopeOnDisk for that root can acquire the lock, pass knowsBlockRoot (the root is re-added to f.blocks by AddChainSegment, which stores the block early at fork_graph_disk.go:305 with no lowest-slot gate), rename its temp into place and set envelopeExists. Prune then deletes the file it just wrote.
Result: HasEnvelope(root) == true with no file on disk, and every subsequent ReadEnvelopeFromDisk fails with ErrNotExist — the sentinel ExecutionPayloadEnvelopesByRoot handler advertises the envelope and then errors.
Moving both Remove calls inside the same critical section as the map deletes closes it.
| if e.BuilderExits == nil { | ||
| return fmt.Errorf("nil builder exit requests") | ||
| } | ||
| if err := e.Withdrawals.ValidateBounds(int(cfg.MaxWithdrawalRequestsPerPayload)); err != nil { |
There was a problem hiding this comment.
These four lists are progressive in Gloas, so a hard len <= configured max check rejects what the decoder accepts.
For Gloas, ensureLists builds all five via NewStaticProgressiveListSSZ, whose limit is progressiveDecodeLimit(configLimit) = 2 * configLimit. progressiveDecodeLimit's own comment says: "Progressive lists are semantically unbounded, so decode limits are resource guards rather than protocol maxima." validateForPersistence follows that for Deposits (ValidateProgressiveDecodeBounds, i.e. 2x), but lines 240-251 apply the raw 1x protocol maximum to the other four.
Two consequences:
- Inconsistent: an envelope with
MaxDepositRequestsPerPayload + 1deposits is accepted, one withMaxWithdrawalRequestsPerPayload + 1withdrawals is rejected. - Read-side amplification:
ReadEnvelopeFromDiskalso runs this, and a failure setsinvalidEnvelopespermanently. Any config tightening of these four maxima turns already-persisted envelopes into permanently unreadable ones and killsHasEnvelopefor those roots.
Per cl/CLAUDE.md ("Review all changes against the upstream Ethereum consensus specifications"), either use ValidateProgressiveDecodeBounds for all five or drop the four 1x checks.
|
|
||
| func (f *ForkChoiceStorageMock) OnExecutionPayload(ctx context.Context, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error { | ||
| if f.OnExecutionPayloadErr == nil && signedEnvelope != nil && signedEnvelope.Message != nil { | ||
| f.Envelopes[signedEnvelope.Message.BeaconBlockRoot] = signedEnvelope |
There was a problem hiding this comment.
Unsynchronised map write, plus a nil-map panic on literal-constructed mocks.
ForkChoiceStorageMock has no mutex. executionPayloadService runs go s.loop(ctx) from its constructor; processPendingEnvelopes calls ProcessMessage -> OnExecutionPayload (this write) while the test goroutine calls ProcessMessage -> HasEnvelope, which reads f.Envelopes at line 442. That is a concurrent map read + write, which is a Go runtime fatal error, not just a -race report.
Separately, Envelopes is only allocated in NewForkChoiceStorageMock. Several tests build the mock as a literal (&mock_services.ForkChoiceStorageMock{} in cl/sentinel/handlers/blocks_by_root_test.go:91, blobs_test.go:116, rate_limiter_integration_test.go:65, ...). Any of those reaching OnExecutionPayload panics with "assignment to entry in nil map".
Guard the write with a mutex and lazily allocate the map.
| envelopeLength := binary.BigEndian.Uint64(lengthBytes) | ||
| if envelopeLength > maxSSZObjectSize { | ||
| return nil, fmt.Errorf("corrupt envelope file: length %d exceeds max %d, root: %x", envelopeLength, maxSSZObjectSize, blockRoot) | ||
| if envelopeLength > clparams.MaxChunkSize { |
There was a problem hiding this comment.
clparams.MaxChunkSize is the package constant (mainnet 15 MiB), not the per-network f.beaconCfg.MaxChunkSize.
MAX_CHUNK_SIZE is a configurable preset — clparams.BeaconChainConfig carries it as a yaml-bound field (config.go:225) and networks can and do override it. Using the compile-time constant here (and at lines 346 and 368, and in readEnvelopeHTTPBody in both beacon_downloader.go and remote_checkpoint_sync.go) silently applies mainnet's bound to every chain.
f.beaconCfg / b.beaconCfg / r.beaconConfig are in scope at every one of those sites.
| ownedTransactions[i] = bytes.Clone(transaction) | ||
| } | ||
| // TransactionsSSZ decode aliases its input, so detach it before reusing the shared buffer. | ||
| envelope.Message.Payload.Transactions = solid.NewTransactionsSSZFromTransactions(ownedTransactions) |
There was a problem hiding this comment.
The detach drops the decoder limits that Eth1Block.decodeSSZ installed.
Decoding builds the field as solid.NewTransactionsSSZWithLimits(cfg.MaxTransactionsPerPayload, cfg.MaxBytesPerTransaction); NewTransactionsSSZFromTransactions sets only underlying, leaving both limits zero. maxTransactions() / maxBytes() then fall back to MaxTransactionsPerPayloadDefault (1<<20) and MaxBytesPerTransactionDefault (1<<30).
The returned envelope is handed to the sentinel handlers and to chain_tip_sync, and Eth1Block.Clone() round-trips it through DecodeSSZ — that re-decode now uses the defaults instead of this chain's configured maxima.
A NewTransactionsSSZFromTransactionsWithLimits (or copying the two fields onto the new value) keeps the detach without losing the bounds.
| f.blockRewards.Delete(root) | ||
| } | ||
| for _, root := range oldRoots { | ||
| f.stateDumpLock.Lock() |
There was a problem hiding this comment.
Prune now blocks on stateDumpLock and performs file I/O under it, on the fork-choice drain path.
Prune is called from drainQueuedWork (cl/phase1/forkchoice/utils.go:69), which runs right after the fork-choice mutex is released. stateDumpLock is held for the whole of DumpBeaconStateOnDisk / readBeaconStateFromDisk — a mainnet beacon state is hundreds of MB of encode + snappy + Sync(). Previously Prune took no lock at all; now each of up to SlotsPerEpoch-many roots waits behind one of those, and f.fs.Remove(getBeaconStateFilename(root)) runs inside the critical section too.
Only the map mutations need the lock — hoist f.fs.Remove out (together with the envelope removes, see the other comment on this loop), or take the lock once for the whole map-mutation pass.
| } else { | ||
| f.sszSnappyReader.Reset(file) | ||
| readTracker := &envelopeReadTracker{Reader: file} | ||
| snappyReader := snappy.NewReader(readTracker) |
There was a problem hiding this comment.
A fresh snappy.Reader per envelope read.
snappy.NewReader allocates ~141 KB (a 76 KB buf plus a 65 KB decoded). This path is already serialised by stateDumpLock, exactly like readBeaconStateFromDisk which reuses the pooled f.sszSnappyReader via Reset (line 70). The sentinel ExecutionPayloadEnvelopesByRange/ByRoot handlers call this once per served envelope.
Add an envelopeSnappyReader *snappy.Reader field and Reset(readTracker) it, mirroring the state path.
| return fetched | ||
| } | ||
|
|
||
| func readEnvelopeHTTPBody(r io.Reader) ([]byte, error) { |
There was a problem hiding this comment.
Duplicated helper. readEnvelopeHTTPBody is byte-identical to the one added at cl/phase1/core/checkpoint_sync/remote_checkpoint_sync.go:199.
Both read an envelope response under the same bound. Put one copy somewhere both packages can reach (e.g. next to the envelope types in cl/cltypes, or a small helper in cl/clparams/cl/utils) so the bound cannot drift between the two fetch paths.
| filename := getEnvelopeFilename(blockRoot) | ||
| file, err = f.fs.Open(filename) | ||
| if err != nil { | ||
| if !wasCached || errors.Is(err, os.ErrNotExist) { |
There was a problem hiding this comment.
Both !wasCached eviction branches are no-ops.
wasCached is exactly envelopeExists.Load(blockRoot) taken at line 228, under the same lock, and nothing stores the key before these branches run. So when !wasCached the key is by definition absent and Delete does nothing.
Line 239 reduces to if errors.Is(err, os.ErrNotExist), and the else if err != nil && !wasCached arm in the deferred func (line 251) is dead in full. Dropping them makes the actual policy — "evict only on ErrNotExist or structural corruption" — readable.
| } | ||
| } | ||
|
|
||
| func (e *ExtraData) ValidateBounds() error { |
There was a problem hiding this comment.
This can never fire for SSZ-decoded extra data.
SetBytes clamps: if e.l > maxExtraDataBytes { e.l = len(e.data) }, and NewExtraData allocates data at exactly maxExtraDataBytes. So after any DecodeSSZ, e.l <= 32 always, and oversized SSZ extra data is silently truncated rather than reported here.
Only UnmarshalJSON (which replaces e.data wholesale) can produce e.l > 32, so the check only covers the REST/JSON path. If the intent is to catch oversized SSZ extra data, the guard belongs in SetBytes/DecodeSSZ returning an error, not in a post-hoc bounds check that the setter has already made unreachable.
| return len(b.data) | ||
| } | ||
|
|
||
| func (b *ByteListSSZ) ValidateBounds(limit uint64) error { |
There was a problem hiding this comment.
The bound is taken as a parameter while the instance already carries b.limit.
ByteListSSZ is constructed with its own limit (and DecodeSSZ/SetBytes enforce it). Taking a second, unrelated limit here means a caller that passes a different value than the one used at construction validates against the wrong maximum with no signal — and ValidateForPersistence does exactly that, passing cfg.MaxBytesPerTransaction to a list that may have been built from another config.
Either validate against b.limit and drop the parameter, or assert the two agree.
| for _, root := range oldRoots { | ||
| f.stateDumpLock.Lock() | ||
| f.blocks.Delete(root) | ||
| f.fs.Remove(getBeaconStateFilename(root)) |
There was a problem hiding this comment.
Asymmetric error handling in the same loop. The two envelope removes below now join their errors into the returned err, but the beacon-state remove on this line still discards its result.
A failing state-file remove leaks a multi-hundred-MB file per pruned root with no signal anywhere. If removal failures are worth surfacing for envelopes, they are worth surfacing for the much larger state files.
| return fmt.Errorf("cannot persist envelope for root %x with embedded root %x", blockRoot, envelope.Message.BeaconBlockRoot) | ||
| } | ||
| envelopeSize := envelope.EncodingSizeSSZ() | ||
| if envelopeSize < 0 || uint64(envelopeSize) > clparams.MaxChunkSize { |
There was a problem hiding this comment.
Redundant pre-check. Line 368 already rejects on len(f.sszBuffer) > MaxChunkSize using the actual encoded length, which is the authoritative value.
This earlier EncodingSizeSSZ() check adds a second, weaker bound and is not free of side effects: Eth1Block.EncodingSizeSSZ lazily assigns b.Withdrawals / b.BlockAccessList and ExecutionRequests.EncodingSizeSSZ calls ensureLists(), so the caller's envelope is mutated during what reads as a validation step. Drop it and keep the post-encode check.
| go func() { | ||
| dumpDone <- f.DumpEnvelopeOnDisk(retainedRoot, testEnvelopeWithTransaction(retainedRoot, []byte{1})) | ||
| }() | ||
| time.Sleep(10 * time.Millisecond) |
There was a problem hiding this comment.
Wall-clock synchronisation makes these prune tests flaky on loaded CI.
This time.Sleep(10ms) is meant to let the DumpEnvelopeOnDisk goroutine reach stateDumpLock; on a contended runner it may not have been scheduled yet, and the test then asserts the wrong thing. TestPruneDoesNotRaceEnvelopeReplacement has the mirror problem — its time.After(time.Second) arm makes the test always burn a full second on the expected path.
Both are expressible with the channel handshake the fixtures already provide (envelopeBlockingPruneFs.firstReached / releaseFirst): add a channel the dump goroutine closes once it is inside DumpEnvelopeOnDisk, and wait on that instead.
Summary
HasEnvelopeon its cache-only hot path; only successful writes and validated reads promote the cacheProduction scope
Caplin creates the fork-choice filesystem under
dirs.Tmp/caplin-forkchoiceand clears it before constructing the fork graph on every process start. This PR therefore provides intra-process atomicity, ownership safety, and prune ordering; it intentionally does not claim crash durability or startup recovery. Directory fsync, quarantine files, startup scavenging, and committed-with-durability-warning handling are outside the real lifecycle and are not included.Beacon-state persistence remains unchanged. It has a different file format and lifecycle and should be hardened separately rather than expanding this envelope-focused change.
Root cause
Envelope writes truncated the final file in place, so a failed replacement could destroy the previously readable envelope and a concurrent reader could observe partial data. Reads decoded transaction slices from the fork graph's reusable SSZ buffer, allowing a later read or write to mutate an envelope already returned to a caller. Pruning also removed envelope files without coordinating with writers, allowing a concurrent rename to restore a pruned file.
Validation
go test ./cl/phase1/forkchoice/fork_graph ./cl/phase1/forkchoice ./cl/phase1/network/services ./cl/cltypes ./cl/cltypes/solid -count=1go test -racefor cache misses, read ownership, dump/read concurrency, prune/write ordering, write failures, and post-Gloas round tripsmake linttwicemake erigon integrationThe behavior changes were driven Red to Green through public network-service, forkchoice, and fork-graph methods. Regressions cover destructive replacement failures, shared-buffer mutation, prune/write resurrection, unrelated I/O during multi-root pruning, cache misses waiting on state I/O, transient open/read failures preserving trusted availability, structural corruption evicting warm cache, post-Gloas version loss, nested-version mismatch, null list members before hashing, protocol-versus-decoder bounds, alternate collection representations before persistence, double close, root/file identity mismatch, oversized allocation, malformed nested input, and failed-removal cache exposure.